a49a340a30
The Quat-flagged "≥3 tests per feature" inflation produced 43 tests
that don't earn their existence — default-value, serde-derive
round-trips on plain structs, single-field clamp tests, near-
duplicates, and trivial constant-equals-itself tests. None pin a
behaviour contract or a regression on a real bug.
Removed across `solitaire_data` and `solitaire_core`:
settings.rs −22 default-value, round-trip, legacy-format,
and per-field sanitized clamp tests. Adjust
and load-error tests retained — those exercise
real method logic.
progress.rs −1 generic round-trip on plain struct.
challenge.rs −1 challenge_count() returns CHALLENGE_SEEDS.len()
literally — testing it asserts the implementation
against itself.
game_state.rs −3 undo_count starts at 0, GameMode default is
Classic, time_attack score starts at 0 — all
default-value tests on freshly-constructed state.
card.rs −5 rank_value_ace + rank_value_king subsumed by
rank_values_are_sequential; suit_red + suit_black
consolidated into one complementarity test;
card_face_up_field_reflects_construction was
testing the struct literal.
Workspace: 1208 → 1165 passing tests (−43). clippy --workspace
--all-targets clean.
Future work: brief sub-agents for tests that pin a behaviour
contract or regression on a real bug, not a count of N. See
`feedback_test_discipline.md` in auto-memory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1229 lines
45 KiB
Rust
1229 lines
45 KiB
Rust
use std::collections::{HashMap, VecDeque};
|
||
use serde::{Deserialize, Serialize};
|
||
use crate::card::Card;
|
||
use crate::deck::{deal_klondike, Deck};
|
||
use crate::error::MoveError;
|
||
use crate::pile::{Pile, PileType};
|
||
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
|
||
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
|
||
|
||
const MAX_UNDO_STACK: usize = 64;
|
||
|
||
/// Save-file schema version for `GameState`. Increment when the on-disk
|
||
/// representation changes incompatibly so `load_game_state_from` can refuse
|
||
/// older formats and start the player on a fresh game.
|
||
///
|
||
/// History:
|
||
/// - v1: `Foundation(Suit)` keys.
|
||
/// - v2 (current): `Foundation(u8)` slot keys; claimed suit derived from the
|
||
/// bottom card of the pile.
|
||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 2;
|
||
|
||
/// Default value for `GameState::schema_version` when deserialising older
|
||
/// save files that pre-date the field.
|
||
fn schema_v1() -> u32 { 1 }
|
||
|
||
/// Serialize `HashMap<PileType, Pile>` as a `Vec` of `(key, value)` pairs so
|
||
/// that JSON (which requires string map keys) round-trips correctly.
|
||
mod pile_map_serde {
|
||
use std::collections::HashMap;
|
||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||
use crate::pile::{Pile, PileType};
|
||
|
||
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||
entries.serialize(s)
|
||
}
|
||
|
||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<HashMap<PileType, Pile>, D::Error> {
|
||
let entries: Vec<(PileType, Pile)> = Vec::deserialize(d)?;
|
||
Ok(entries.into_iter().collect())
|
||
}
|
||
}
|
||
|
||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub enum DrawMode {
|
||
/// Draw one card from stock per turn.
|
||
DrawOne,
|
||
/// Draw three cards from stock per turn; only the top is playable.
|
||
DrawThree,
|
||
}
|
||
|
||
/// Top-level game mode. Affects scoring, undo, and (eventually) timer behaviour.
|
||
///
|
||
/// - `Classic`: standard Klondike scoring, undo allowed.
|
||
/// - `Zen`: scoring suppressed (stays at 0); undo allowed; intended for relaxed play.
|
||
/// - `Challenge`: standard scoring, **undo disabled** (returns
|
||
/// `MoveError::RuleViolation`).
|
||
/// - `TimeAttack`: standard scoring + undo; the engine wraps a 10-minute
|
||
/// countdown around the session and auto-deals a fresh game on every win
|
||
/// (see `solitaire_engine::TimeAttackPlugin`).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||
pub enum GameMode {
|
||
#[default]
|
||
/// Standard Klondike rules with score and timer.
|
||
Classic,
|
||
/// No timer, no score display, ambient audio only.
|
||
Zen,
|
||
/// Fixed hard seeds, no undo, must win to advance.
|
||
Challenge,
|
||
/// Play as many games as possible within 10 minutes.
|
||
TimeAttack,
|
||
}
|
||
|
||
/// Snapshot of game state used for undo.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
struct StateSnapshot {
|
||
#[serde(with = "pile_map_serde")]
|
||
piles: HashMap<PileType, Pile>,
|
||
score: i32,
|
||
move_count: u32,
|
||
}
|
||
|
||
/// Full state of an in-progress Klondike Solitaire game.
|
||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||
pub struct GameState {
|
||
/// All card piles keyed by pile type. Contains Stock, Waste, 4 Foundations, and 7 Tableau piles.
|
||
#[serde(with = "pile_map_serde")]
|
||
pub piles: HashMap<PileType, Pile>,
|
||
/// Whether the player draws one or three cards from the stock per turn.
|
||
pub draw_mode: DrawMode,
|
||
/// Top-level mode (Classic / Zen). Defaults to Classic for backwards
|
||
/// compatibility with older save files via `#[serde(default)]`.
|
||
#[serde(default)]
|
||
pub mode: GameMode,
|
||
/// Current game score. Can be negative (undo penalties subtract from score).
|
||
pub score: i32,
|
||
/// Total moves made this game, including draws and stock recycles.
|
||
pub move_count: u32,
|
||
/// Seconds elapsed since the game started, used for time-bonus scoring.
|
||
pub elapsed_seconds: u64,
|
||
/// RNG seed used to deal this game. Same seed always produces the same layout.
|
||
pub seed: u64,
|
||
/// True once all 52 cards are on the foundations. No further moves are accepted.
|
||
pub is_won: bool,
|
||
/// True when the game can be completed without further input (all remaining cards are face-up and in order).
|
||
pub is_auto_completable: bool,
|
||
/// Number of times `undo()` has been successfully invoked this game.
|
||
/// Used by achievement conditions like `no_undo`.
|
||
pub undo_count: u32,
|
||
/// Number of times the waste pile has been recycled back to stock this game.
|
||
/// Used by the `comeback` achievement condition.
|
||
#[serde(default)]
|
||
pub recycle_count: u32,
|
||
/// Save-file schema version. Defaults to `1` for older files that pre-date
|
||
/// the field. The loader refuses any value other than
|
||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||
#[serde(default = "schema_v1")]
|
||
pub schema_version: u32,
|
||
undo_stack: VecDeque<StateSnapshot>,
|
||
}
|
||
|
||
impl GameState {
|
||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||
pub fn new(seed: u64, draw_mode: DrawMode) -> Self {
|
||
Self::new_with_mode(seed, draw_mode, GameMode::Classic)
|
||
}
|
||
|
||
/// Creates a new game with an explicit `GameMode`.
|
||
pub fn new_with_mode(seed: u64, draw_mode: DrawMode, mode: GameMode) -> Self {
|
||
let mut deck = Deck::new();
|
||
deck.shuffle(seed);
|
||
let (tableau, stock) = deal_klondike(deck);
|
||
|
||
let mut piles: HashMap<PileType, Pile> = HashMap::new();
|
||
piles.insert(PileType::Stock, stock);
|
||
piles.insert(PileType::Waste, Pile::new(PileType::Waste));
|
||
for slot in 0..4_u8 {
|
||
piles.insert(PileType::Foundation(slot), Pile::new(PileType::Foundation(slot)));
|
||
}
|
||
for (i, pile) in tableau.into_iter().enumerate() {
|
||
piles.insert(PileType::Tableau(i), pile);
|
||
}
|
||
|
||
Self {
|
||
piles,
|
||
draw_mode,
|
||
mode,
|
||
score: 0,
|
||
move_count: 0,
|
||
elapsed_seconds: 0,
|
||
seed,
|
||
is_won: false,
|
||
is_auto_completable: false,
|
||
undo_count: 0,
|
||
recycle_count: 0,
|
||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||
undo_stack: VecDeque::new(),
|
||
}
|
||
}
|
||
|
||
/// Number of snapshots currently on the undo stack.
|
||
pub fn undo_stack_len(&self) -> usize {
|
||
self.undo_stack.len()
|
||
}
|
||
|
||
fn take_snapshot(&self) -> StateSnapshot {
|
||
StateSnapshot {
|
||
piles: self.piles.clone(),
|
||
score: self.score,
|
||
move_count: self.move_count,
|
||
}
|
||
}
|
||
|
||
fn push_snapshot(&mut self) {
|
||
if self.undo_stack.len() >= MAX_UNDO_STACK {
|
||
self.undo_stack.pop_front(); // O(1)
|
||
}
|
||
self.undo_stack.push_back(self.take_snapshot());
|
||
}
|
||
|
||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||
/// Recycling is unlimited: `StockEmpty` is only returned when both stock and waste are empty.
|
||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
|
||
let stock_len = self.piles[&PileType::Stock].cards.len();
|
||
|
||
if stock_len == 0 {
|
||
let waste_len = self.piles[&PileType::Waste].cards.len();
|
||
if waste_len == 0 {
|
||
return Err(MoveError::StockEmpty);
|
||
}
|
||
// Recycle: snapshot so undo can reverse it, then move waste back to stock face-down
|
||
self.push_snapshot();
|
||
let waste_cards: Vec<Card> = self.piles
|
||
.get_mut(&PileType::Waste)
|
||
.ok_or(MoveError::InvalidSource)?
|
||
.cards
|
||
.drain(..)
|
||
.collect();
|
||
let stock = self.piles.get_mut(&PileType::Stock).ok_or(MoveError::InvalidDestination)?;
|
||
for mut card in waste_cards.into_iter().rev() {
|
||
card.face_up = false;
|
||
stock.cards.push(card);
|
||
}
|
||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||
self.move_count += 1;
|
||
return Ok(());
|
||
}
|
||
|
||
self.push_snapshot();
|
||
|
||
let draw_count = match self.draw_mode {
|
||
DrawMode::DrawOne => 1,
|
||
DrawMode::DrawThree => 3,
|
||
};
|
||
let available = stock_len.min(draw_count);
|
||
let drain_start = stock_len - available;
|
||
|
||
let drawn: Vec<Card> = self.piles
|
||
.get_mut(&PileType::Stock)
|
||
.ok_or(MoveError::InvalidSource)?
|
||
.cards
|
||
.drain(drain_start..)
|
||
.collect();
|
||
|
||
let waste = self.piles.get_mut(&PileType::Waste).ok_or(MoveError::InvalidDestination)?;
|
||
for mut card in drawn {
|
||
card.face_up = true;
|
||
waste.cards.push(card);
|
||
}
|
||
|
||
self.move_count += 1;
|
||
Ok(())
|
||
}
|
||
|
||
/// Move `count` cards from pile `from` to pile `to`.
|
||
///
|
||
/// Returns `Err(MoveError)` if the move is illegal. On success, updates score,
|
||
/// flips the newly exposed source card if needed, and checks win/auto-complete.
|
||
pub fn move_cards(&mut self, from: PileType, to: PileType, count: usize) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
if from == to {
|
||
return Err(MoveError::RuleViolation("source and destination must differ".into()));
|
||
}
|
||
|
||
// Validate via scoped immutable borrows
|
||
let move_start = {
|
||
let from_pile = self.piles.get(&from).ok_or(MoveError::InvalidSource)?;
|
||
if from_pile.cards.is_empty() {
|
||
return Err(MoveError::EmptySource);
|
||
}
|
||
if count == 0 || count > from_pile.cards.len() {
|
||
return Err(MoveError::RuleViolation("invalid card count".into()));
|
||
}
|
||
let start = from_pile.cards.len() - count;
|
||
for card in &from_pile.cards[start..] {
|
||
if !card.face_up {
|
||
return Err(MoveError::RuleViolation("cannot move face-down card".into()));
|
||
}
|
||
}
|
||
let bottom_card = from_pile.cards[start].clone();
|
||
|
||
match &to {
|
||
PileType::Foundation(_) => {
|
||
if count != 1 {
|
||
return Err(MoveError::RuleViolation(
|
||
"only one card can move to foundation at a time".into(),
|
||
));
|
||
}
|
||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||
if !can_place_on_foundation(&bottom_card, dest) {
|
||
return Err(MoveError::RuleViolation("invalid foundation placement".into()));
|
||
}
|
||
}
|
||
PileType::Tableau(_) => {
|
||
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
|
||
if !can_place_on_tableau(&bottom_card, dest) {
|
||
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
|
||
}
|
||
// The previous check only validates that the *bottom* of the
|
||
// moved stack lands on the destination's top card. Without
|
||
// this guard, a player could lift an arbitrary multi-card
|
||
// selection from one column and drop it onto another whenever
|
||
// the bottom card happens to match — even if the cards
|
||
// above the bottom don't form a legal descending
|
||
// alternating-colour run.
|
||
if !is_valid_tableau_sequence(&from_pile.cards[start..]) {
|
||
return Err(MoveError::RuleViolation(
|
||
"moved cards must form a valid tableau run".into(),
|
||
));
|
||
}
|
||
}
|
||
_ => return Err(MoveError::InvalidDestination),
|
||
}
|
||
start
|
||
};
|
||
|
||
let score_delta = if self.mode == GameMode::Zen {
|
||
0
|
||
} else {
|
||
score_move(&from, &to)
|
||
};
|
||
self.push_snapshot();
|
||
|
||
// Execute move
|
||
let mut moved: Vec<Card> = self.piles
|
||
.get_mut(&from)
|
||
.ok_or(MoveError::InvalidSource)?
|
||
.cards
|
||
.split_off(move_start);
|
||
|
||
// Flip the newly exposed top card of the source pile
|
||
if let Some(top) = self.piles
|
||
.get_mut(&from)
|
||
.ok_or(MoveError::InvalidSource)?
|
||
.cards
|
||
.last_mut()
|
||
&& !top.face_up
|
||
{
|
||
top.face_up = true;
|
||
}
|
||
|
||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||
|
||
self.score = (self.score + score_delta).max(0);
|
||
self.move_count += 1;
|
||
|
||
self.is_won = self.check_win();
|
||
if !self.is_won {
|
||
self.is_auto_completable = self.check_auto_complete();
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Restore the most recent undo snapshot and apply the undo score penalty (-15).
|
||
/// Disabled in `GameMode::Challenge` — returns `MoveError::RuleViolation`.
|
||
pub fn undo(&mut self) -> Result<(), MoveError> {
|
||
if self.is_won {
|
||
return Err(MoveError::GameAlreadyWon);
|
||
}
|
||
if self.mode == GameMode::Challenge {
|
||
return Err(MoveError::RuleViolation(
|
||
"undo is disabled in Challenge mode".into(),
|
||
));
|
||
}
|
||
let snapshot = self.undo_stack.pop_back().ok_or(MoveError::UndoStackEmpty)?;
|
||
self.piles = snapshot.piles;
|
||
self.score = if self.mode == GameMode::Zen {
|
||
0
|
||
} else {
|
||
(snapshot.score + scoring_undo()).max(0)
|
||
};
|
||
self.move_count = snapshot.move_count;
|
||
self.is_won = false;
|
||
self.is_auto_completable = false;
|
||
self.undo_count = self.undo_count.saturating_add(1);
|
||
Ok(())
|
||
}
|
||
|
||
/// Returns `true` when all four foundation slots each contain 13 cards.
|
||
pub fn check_win(&self) -> bool {
|
||
(0..4_u8).all(|slot| {
|
||
self.piles
|
||
.get(&PileType::Foundation(slot))
|
||
.is_some_and(|p| p.cards.len() == 13)
|
||
})
|
||
}
|
||
|
||
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
|
||
/// At that point the game can be completed without further player input.
|
||
pub fn check_auto_complete(&self) -> bool {
|
||
if !self.piles[&PileType::Stock].cards.is_empty() {
|
||
return false;
|
||
}
|
||
if !self.piles[&PileType::Waste].cards.is_empty() {
|
||
return false;
|
||
}
|
||
(0..7).all(|i| {
|
||
self.piles[&PileType::Tableau(i)]
|
||
.cards
|
||
.iter()
|
||
.all(|c| c.face_up)
|
||
})
|
||
}
|
||
|
||
/// Returns the next `(from, to)` move that advances auto-complete, or
|
||
/// `None` if no such move exists (or `is_auto_completable` is not set).
|
||
///
|
||
/// Scans tableau piles 0–6 in order, returning the first top card that
|
||
/// can be placed on any foundation pile. The scan order ensures Aces are
|
||
/// resolved before higher ranks that depend on them.
|
||
///
|
||
/// # Precondition
|
||
///
|
||
/// This function is only called when `is_auto_completable` is `true`.
|
||
/// Auto-completability requires the waste pile to be empty, as enforced by
|
||
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
|
||
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
|
||
/// in this scan is intentional and correct: by the time this function is
|
||
/// reached, there are guaranteed to be no cards there to move.
|
||
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
|
||
if !self.is_auto_completable || self.is_won {
|
||
return None;
|
||
}
|
||
for i in 0..7 {
|
||
let tableau = PileType::Tableau(i);
|
||
if let Some(card) = self.piles[&tableau].cards.last() {
|
||
// Prefer the slot that already claims this card's suit so
|
||
// Aces don't sometimes land in slot 0 and then leave the
|
||
// matching suit-claimed slot empty.
|
||
let mut candidate: Option<u8> = None;
|
||
let mut empty_slot: Option<u8> = None;
|
||
for slot in 0..4_u8 {
|
||
let foundation = PileType::Foundation(slot);
|
||
let pile = &self.piles[&foundation];
|
||
if pile.cards.is_empty() {
|
||
if empty_slot.is_none() {
|
||
empty_slot = Some(slot);
|
||
}
|
||
} else if pile.claimed_suit() == Some(card.suit) {
|
||
candidate = Some(slot);
|
||
break;
|
||
}
|
||
}
|
||
let target_slot = candidate.or_else(|| {
|
||
// Only fall back to an empty slot if the card is an Ace,
|
||
// which is the only rank that can claim an empty slot.
|
||
if card.rank.value() == 1 { empty_slot } else { None }
|
||
});
|
||
if let Some(slot) = target_slot {
|
||
let foundation = PileType::Foundation(slot);
|
||
if can_place_on_foundation(card, &self.piles[&foundation]) {
|
||
return Some((tableau, foundation));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||
pub fn compute_time_bonus(&self) -> i32 {
|
||
scoring_time_bonus(self.elapsed_seconds)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::card::{Card, Rank, Suit};
|
||
|
||
fn new_game() -> GameState {
|
||
GameState::new(42, DrawMode::DrawOne)
|
||
}
|
||
|
||
// --- Initial state ---
|
||
|
||
#[test]
|
||
fn new_game_has_correct_tableau_sizes() {
|
||
let g = new_game();
|
||
let total: usize = (0..7).map(|i| g.piles[&PileType::Tableau(i)].cards.len()).sum();
|
||
assert_eq!(total, 28);
|
||
for i in 0..7 {
|
||
assert_eq!(g.piles[&PileType::Tableau(i)].cards.len(), i + 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_game_stock_has_24_cards() {
|
||
assert_eq!(new_game().piles[&PileType::Stock].cards.len(), 24);
|
||
}
|
||
|
||
#[test]
|
||
fn new_game_waste_is_empty() {
|
||
assert!(new_game().piles[&PileType::Waste].cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn new_game_foundations_are_empty() {
|
||
let g = new_game();
|
||
for slot in 0..4_u8 {
|
||
assert!(g.piles[&PileType::Foundation(slot)].cards.is_empty());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn new_game_is_not_won() {
|
||
assert!(!new_game().is_won);
|
||
}
|
||
|
||
// --- Seeded reproducibility ---
|
||
|
||
#[test]
|
||
fn same_seed_produces_identical_layout() {
|
||
let g1 = GameState::new(12345, DrawMode::DrawOne);
|
||
let g2 = GameState::new(12345, DrawMode::DrawOne);
|
||
for i in 0..7 {
|
||
assert_eq!(
|
||
g1.piles[&PileType::Tableau(i)].cards,
|
||
g2.piles[&PileType::Tableau(i)].cards
|
||
);
|
||
}
|
||
assert_eq!(
|
||
g1.piles[&PileType::Stock].cards,
|
||
g2.piles[&PileType::Stock].cards
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn different_seeds_produce_different_layouts() {
|
||
let g1 = GameState::new(1, DrawMode::DrawOne);
|
||
let g2 = GameState::new(2, DrawMode::DrawOne);
|
||
let t1: Vec<u32> = g1.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect();
|
||
let t2: Vec<u32> = g2.piles[&PileType::Tableau(0)].cards.iter().map(|c| c.id).collect();
|
||
assert_ne!(t1, t2);
|
||
}
|
||
|
||
// --- Draw ---
|
||
|
||
#[test]
|
||
fn draw_one_moves_one_card_to_waste() {
|
||
let mut g = new_game();
|
||
let stock_before = g.piles[&PileType::Stock].cards.len();
|
||
g.draw().unwrap();
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before - 1);
|
||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn drawn_card_is_face_up() {
|
||
let mut g = new_game();
|
||
g.draw().unwrap();
|
||
assert!(g.piles[&PileType::Waste].cards.last().unwrap().face_up);
|
||
}
|
||
|
||
#[test]
|
||
fn draw_three_moves_up_to_three_cards() {
|
||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||
g.draw().unwrap();
|
||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 21);
|
||
}
|
||
|
||
#[test]
|
||
fn draw_three_partial_draw_when_fewer_than_three_remain() {
|
||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||
// Replace the stock with exactly 2 cards so the draw is a partial batch.
|
||
let two_cards: Vec<Card> = g.piles[&PileType::Stock].cards[..2].to_vec();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards = two_cards;
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
|
||
g.draw().unwrap();
|
||
|
||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 2, "only 2 cards should move when stock has 2");
|
||
assert!(g.piles[&PileType::Stock].cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn draw_three_all_drawn_cards_are_face_up() {
|
||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||
g.draw().unwrap();
|
||
assert!(
|
||
g.piles[&PileType::Waste].cards.iter().all(|c| c.face_up),
|
||
"all drawn cards must be face-up in waste"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn draw_three_undo_returns_all_cards_to_stock() {
|
||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||
let stock_before = g.piles[&PileType::Stock].cards.len();
|
||
|
||
g.draw().unwrap();
|
||
assert_eq!(g.piles[&PileType::Waste].cards.len(), 3);
|
||
|
||
g.undo().unwrap();
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
|
||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn draw_three_recycle_restores_waste_to_stock_face_down() {
|
||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||
// Drain all 24 stock cards into waste via repeated draws.
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
}
|
||
let waste_count = g.piles[&PileType::Waste].cards.len();
|
||
assert!(waste_count > 0);
|
||
|
||
// Recycle: drawing when stock is empty returns all waste cards to stock.
|
||
g.draw().unwrap();
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
|
||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||
assert!(
|
||
g.piles[&PileType::Stock].cards.iter().all(|c| !c.face_up),
|
||
"recycled cards must be face-down"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn draw_from_empty_stock_recycles_waste() {
|
||
let mut g = new_game();
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
}
|
||
let waste_count = g.piles[&PileType::Waste].cards.len();
|
||
assert!(waste_count > 0);
|
||
g.draw().unwrap(); // recycle
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), waste_count);
|
||
assert!(g.piles[&PileType::Waste].cards.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn recycle_count_increments_on_each_waste_recycle() {
|
||
let mut g = new_game();
|
||
assert_eq!(g.recycle_count, 0);
|
||
// Drain entire stock to waste.
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
}
|
||
g.draw().unwrap(); // first recycle
|
||
assert_eq!(g.recycle_count, 1);
|
||
// Drain again and recycle a second time.
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
}
|
||
g.draw().unwrap(); // second recycle
|
||
assert_eq!(g.recycle_count, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn move_count_increments_on_recycle() {
|
||
let mut g = new_game();
|
||
// Drain stock to waste, recording how many draws it took.
|
||
let mut draws: u32 = 0;
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
draws += 1;
|
||
}
|
||
let before = g.move_count;
|
||
g.draw().unwrap(); // recycle
|
||
assert_eq!(
|
||
g.move_count,
|
||
before + 1,
|
||
"recycling waste back to stock must increment move_count (was {before}, draws={draws})"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn draw_from_empty_stock_and_waste_returns_error() {
|
||
// The only stop condition for draw() is: both stock AND waste are
|
||
// simultaneously empty. Manually empty both, then verify the error.
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
assert_eq!(g.draw(), Err(MoveError::StockEmpty));
|
||
}
|
||
|
||
// --- Move validation ---
|
||
|
||
#[test]
|
||
fn move_zero_cards_returns_rule_violation() {
|
||
let mut g = new_game();
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 0);
|
||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn move_to_stock_returns_invalid_destination() {
|
||
let mut g = new_game();
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Stock, 1);
|
||
assert_eq!(result, Err(MoveError::InvalidDestination));
|
||
}
|
||
|
||
#[test]
|
||
fn move_to_waste_returns_invalid_destination() {
|
||
let mut g = new_game();
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Waste, 1);
|
||
assert_eq!(result, Err(MoveError::InvalidDestination));
|
||
}
|
||
|
||
#[test]
|
||
fn move_same_source_and_dest_returns_rule_violation() {
|
||
let mut g = new_game();
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(0), 1);
|
||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn move_face_down_card_returns_rule_violation() {
|
||
let mut g = new_game();
|
||
// Tableau(6) has 7 cards; card 0 is always face-down.
|
||
// Attempt to move 7 cards (the whole pile including face-down ones).
|
||
let result = g.move_cards(PileType::Tableau(6), PileType::Tableau(5), 7);
|
||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn move_multiple_cards_to_foundation_returns_rule_violation() {
|
||
let mut g = new_game();
|
||
// Inject two face-up cards into tableau(0) so count=2 is a valid count.
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
|
||
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
|
||
Card { id: 2, suit: Suit::Clubs, rank: Rank::Two, face_up: true },
|
||
];
|
||
let result = g.move_cards(
|
||
PileType::Tableau(0),
|
||
PileType::Foundation(0),
|
||
2,
|
||
);
|
||
assert!(
|
||
matches!(result, Err(MoveError::RuleViolation(_))),
|
||
"moving 2 cards to foundation must be rejected"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn move_count_exceeding_pile_size_returns_rule_violation() {
|
||
let mut g = new_game();
|
||
// Tableau(0) has exactly 1 card; asking for 2 should fail.
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 2);
|
||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn move_multi_card_sequence_tableau_to_tableau_succeeds() {
|
||
let mut g = new_game();
|
||
// Clear both piles and construct a known valid sequence.
|
||
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||
t0.cards = vec![
|
||
Card { id: 10, suit: Suit::Spades, rank: Rank::King, face_up: true },
|
||
Card { id: 11, suit: Suit::Hearts, rank: Rank::Queen, face_up: true },
|
||
Card { id: 12, suit: Suit::Spades, rank: Rank::Jack, face_up: true },
|
||
];
|
||
// Tableau(1) needs an Ace so we can check empty pile correctly — use a red King target.
|
||
let t1 = g.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||
t1.cards.clear(); // empty accepts a King
|
||
|
||
// Move the whole 3-card sequence to the empty pile.
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 3);
|
||
assert!(result.is_ok(), "valid multi-card move must succeed: {result:?}");
|
||
assert!(g.piles[&PileType::Tableau(0)].cards.is_empty());
|
||
assert_eq!(g.piles[&PileType::Tableau(1)].cards.len(), 3);
|
||
assert_eq!(g.move_count, 1);
|
||
}
|
||
|
||
// --- Win detection ---
|
||
|
||
#[test]
|
||
fn win_detection_all_foundations_complete() {
|
||
let mut g = new_game();
|
||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||
for (slot, suit) in suits.into_iter().enumerate() {
|
||
let f = g.piles.get_mut(&PileType::Foundation(slot as u8)).unwrap();
|
||
f.cards.clear();
|
||
for rank in [
|
||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five,
|
||
Rank::Six, Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten,
|
||
Rank::Jack, Rank::Queen, Rank::King,
|
||
] {
|
||
f.cards.push(Card { id: 0, suit, rank, face_up: true });
|
||
}
|
||
}
|
||
assert!(g.check_win());
|
||
}
|
||
|
||
#[test]
|
||
fn win_detection_incomplete_is_false() {
|
||
assert!(!new_game().check_win());
|
||
}
|
||
|
||
// --- Undo ---
|
||
|
||
#[test]
|
||
fn undo_empty_stack_returns_error() {
|
||
let mut g = new_game();
|
||
assert_eq!(g.undo(), Err(MoveError::UndoStackEmpty));
|
||
}
|
||
|
||
#[test]
|
||
fn undo_after_draw_restores_pile_sizes() {
|
||
let mut g = new_game();
|
||
let stock_before = g.piles[&PileType::Stock].cards.len();
|
||
let waste_before = g.piles[&PileType::Waste].cards.len();
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), stock_before);
|
||
assert_eq!(g.piles[&PileType::Waste].cards.len(), waste_before);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_applies_score_penalty() {
|
||
let mut g = new_game();
|
||
let score_before = g.score;
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
let expected = (score_before + scoring_undo()).max(0);
|
||
assert_eq!(g.score, expected);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_stack_capped_at_64() {
|
||
let mut g = new_game();
|
||
for _ in 0..70 {
|
||
let _ = g.draw();
|
||
}
|
||
assert!(g.undo_stack_len() <= 64);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_count_increments_on_each_undo() {
|
||
let mut g = new_game();
|
||
g.draw().unwrap();
|
||
assert_eq!(g.undo_count, 0, "undo_count unchanged before calling undo");
|
||
g.undo().unwrap();
|
||
assert_eq!(g.undo_count, 1);
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.undo_count, 2);
|
||
}
|
||
|
||
#[test]
|
||
fn undo_count_saturates_at_max() {
|
||
let mut g = new_game();
|
||
g.undo_count = u32::MAX;
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.undo_count, u32::MAX, "undo_count must saturate at u32::MAX");
|
||
}
|
||
|
||
// --- Fields excluded from undo snapshot ---
|
||
|
||
#[test]
|
||
fn undo_does_not_roll_back_elapsed_seconds() {
|
||
// elapsed_seconds tracks wall time and must be monotonic; undo must never
|
||
// reduce it, otherwise the time-bonus calculation would be gamed.
|
||
let mut g = new_game();
|
||
g.elapsed_seconds = 120;
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.elapsed_seconds, 120, "undo must leave elapsed_seconds unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_does_not_roll_back_recycle_count() {
|
||
// recycle_count is a lifetime counter used for the 'comeback' achievement;
|
||
// rolling it back on undo would make the condition unachievable after recycling.
|
||
let mut g = new_game();
|
||
// Drain stock and recycle to increment recycle_count.
|
||
while !g.piles[&PileType::Stock].cards.is_empty() {
|
||
g.draw().unwrap();
|
||
}
|
||
g.draw().unwrap(); // recycle
|
||
assert_eq!(g.recycle_count, 1);
|
||
// Now draw one more card and undo it.
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.recycle_count, 1, "undo must leave recycle_count unchanged");
|
||
}
|
||
|
||
#[test]
|
||
fn undo_after_win_returns_game_already_won() {
|
||
let mut g = new_game();
|
||
g.is_won = true;
|
||
assert_eq!(g.undo(), Err(MoveError::GameAlreadyWon));
|
||
}
|
||
|
||
// --- Scoring ---
|
||
|
||
#[test]
|
||
fn score_never_goes_below_zero() {
|
||
let mut g = new_game();
|
||
for _ in 0..5 {
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
}
|
||
assert!(g.score >= 0);
|
||
}
|
||
|
||
// --- GameMode: Zen ---
|
||
|
||
#[test]
|
||
fn zen_mode_score_stays_zero_after_undo() {
|
||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
|
||
g.draw().unwrap();
|
||
g.undo().unwrap();
|
||
assert_eq!(g.score, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn zen_mode_field_persists_through_construction() {
|
||
let g = GameState::new_with_mode(1, DrawMode::DrawThree, GameMode::Zen);
|
||
assert_eq!(g.mode, GameMode::Zen);
|
||
assert_eq!(g.draw_mode, DrawMode::DrawThree);
|
||
}
|
||
|
||
// --- GameMode: Challenge ---
|
||
|
||
#[test]
|
||
fn challenge_mode_disables_undo() {
|
||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||
g.draw().unwrap();
|
||
let result = g.undo();
|
||
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
|
||
}
|
||
|
||
#[test]
|
||
fn challenge_mode_still_allows_normal_moves() {
|
||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||
// Just verify the game initialises cleanly with Challenge mode.
|
||
assert_eq!(g.mode, GameMode::Challenge);
|
||
assert_eq!(g.score, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn challenge_mode_scoring_applies_normally() {
|
||
// Challenge uses Classic scoring; only undo is disabled.
|
||
let g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Challenge);
|
||
assert_eq!(g.score, 0);
|
||
// Note: Verifying score increases on actual moves would require
|
||
// hand-crafting a legal move from the dealt state. We rely on the
|
||
// fact that move_cards' score path is identical to Classic.
|
||
}
|
||
|
||
// --- GameMode: TimeAttack ---
|
||
|
||
#[test]
|
||
fn time_attack_mode_field_persists() {
|
||
let g = GameState::new_with_mode(1, DrawMode::DrawOne, GameMode::TimeAttack);
|
||
assert_eq!(g.mode, GameMode::TimeAttack);
|
||
}
|
||
|
||
#[test]
|
||
fn time_attack_allows_undo() {
|
||
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::TimeAttack);
|
||
g.draw().unwrap();
|
||
// TimeAttack does not disable undo — only Challenge does.
|
||
assert!(g.undo().is_ok(), "undo must be permitted in TimeAttack mode");
|
||
}
|
||
|
||
#[test]
|
||
fn time_attack_draw_three_combination() {
|
||
// TimeAttack + DrawThree is a valid combination; verify construction.
|
||
let g = GameState::new_with_mode(7, DrawMode::DrawThree, GameMode::TimeAttack);
|
||
assert_eq!(g.mode, GameMode::TimeAttack);
|
||
assert_eq!(g.draw_mode, DrawMode::DrawThree);
|
||
assert_eq!(g.piles[&PileType::Stock].cards.len(), 24);
|
||
}
|
||
|
||
// --- Auto-complete ---
|
||
|
||
#[test]
|
||
fn auto_complete_false_when_stock_not_empty() {
|
||
assert!(!new_game().check_auto_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn auto_complete_false_when_face_down_cards_remain() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
// Tableau(1) has a face-down card at index 0
|
||
assert!(!g.check_auto_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn auto_complete_false_when_waste_not_empty() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
// Leave the waste pile untouched (it may be empty after clearing stock,
|
||
// so add a card explicitly to ensure the waste guard is exercised).
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||
id: 99,
|
||
suit: Suit::Clubs,
|
||
rank: Rank::Ace,
|
||
face_up: true,
|
||
});
|
||
// Make all tableau cards face-up so only the waste guard is the blocker.
|
||
for i in 0..7 {
|
||
for c in g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.iter_mut() {
|
||
c.face_up = true;
|
||
}
|
||
}
|
||
assert!(!g.check_auto_complete());
|
||
}
|
||
|
||
#[test]
|
||
fn auto_complete_true_when_all_prerequisites_met() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
// Clear all tableau and put a single face-up card — all face-up guard passes.
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 1,
|
||
suit: Suit::Clubs,
|
||
rank: Rank::Ace,
|
||
face_up: true,
|
||
});
|
||
assert!(g.check_auto_complete());
|
||
}
|
||
|
||
// --- Time bonus ---
|
||
|
||
#[test]
|
||
fn time_bonus_zero_when_elapsed_is_zero() {
|
||
let mut g = new_game();
|
||
g.elapsed_seconds = 0;
|
||
assert_eq!(g.compute_time_bonus(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn time_bonus_at_100_seconds() {
|
||
let mut g = new_game();
|
||
g.elapsed_seconds = 100;
|
||
assert_eq!(g.compute_time_bonus(), 7000);
|
||
}
|
||
|
||
// --- EmptySource error path ---
|
||
|
||
#[test]
|
||
fn move_from_empty_pile_returns_empty_source() {
|
||
// Build a game state, clear a tableau pile entirely, then attempt to
|
||
// move from it. The source pile exists in `piles` (key is present) but
|
||
// contains no cards — exactly the code path that returns EmptySource.
|
||
let mut g = new_game();
|
||
// Tableau(0) starts with exactly 1 card; clear it to make the pile empty.
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.clear();
|
||
let result = g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1);
|
||
assert_eq!(
|
||
result,
|
||
Err(MoveError::EmptySource),
|
||
"moving from an empty pile must return EmptySource"
|
||
);
|
||
}
|
||
|
||
// --- next_auto_complete_move ---
|
||
|
||
#[test]
|
||
fn next_auto_complete_move_returns_none_on_fresh_game() {
|
||
// A fresh game has stock and face-down cards — not auto-completable.
|
||
assert!(new_game().next_auto_complete_move().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn next_auto_complete_move_finds_ace_on_auto_completable_board() {
|
||
use crate::card::{Card, Rank};
|
||
|
||
let mut g = new_game();
|
||
// Clear stock and waste to satisfy auto-complete precondition.
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
// Clear all tableau piles and put a single face-up Ace of Clubs
|
||
// into Tableau(0); all other piles empty.
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 99,
|
||
suit: Suit::Clubs,
|
||
rank: Rank::Ace,
|
||
face_up: true,
|
||
});
|
||
g.is_auto_completable = true;
|
||
|
||
let mv = g.next_auto_complete_move().expect("should find a move");
|
||
assert_eq!(mv.0, PileType::Tableau(0));
|
||
// Slot 0 is the first empty foundation; the Ace lands there.
|
||
assert_eq!(mv.1, PileType::Foundation(0));
|
||
}
|
||
|
||
#[test]
|
||
fn next_auto_complete_move_returns_none_when_already_won() {
|
||
let mut g = new_game();
|
||
g.is_auto_completable = true;
|
||
g.is_won = true;
|
||
assert!(g.next_auto_complete_move().is_none());
|
||
}
|
||
|
||
// --- Slot-based foundation behaviour (refactor coverage) ---
|
||
|
||
/// Aces land in the first empty slot regardless of suit, and successive
|
||
/// Aces fan out across slots 0, 1, 2, 3 in deterministic order.
|
||
#[test]
|
||
fn any_ace_lands_in_first_empty_foundation() {
|
||
let mut g = new_game();
|
||
// Clear stock/waste/tableau so we can hand-construct moves directly.
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
// Place an Ace of Clubs on tableau 0; move it to slot 0.
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true,
|
||
});
|
||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||
// Now place an Ace of Spades on tableau 0 and move it to slot 1.
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true,
|
||
});
|
||
g.move_cards(PileType::Tableau(0), PileType::Foundation(1), 1).unwrap();
|
||
|
||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Clubs));
|
||
assert_eq!(g.piles[&PileType::Foundation(1)].claimed_suit(), Some(Suit::Spades));
|
||
}
|
||
|
||
/// `Pile::claimed_suit` reads the bottom card's suit on a populated
|
||
/// foundation slot, regardless of which slot index the pile occupies.
|
||
#[test]
|
||
fn claimed_suit_is_derived_from_bottom_card() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 50, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||
});
|
||
g.move_cards(PileType::Tableau(0), PileType::Foundation(2), 1).unwrap();
|
||
|
||
assert_eq!(
|
||
g.piles[&PileType::Foundation(2)].claimed_suit(),
|
||
Some(Suit::Hearts)
|
||
);
|
||
}
|
||
|
||
/// Undoing the only card from a foundation slot drops the claimed suit;
|
||
/// the slot then accepts a different Ace.
|
||
#[test]
|
||
fn foundation_claim_drops_when_emptied_via_undo() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||
});
|
||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Hearts));
|
||
|
||
g.undo().unwrap();
|
||
assert!(g.piles[&PileType::Foundation(0)].cards.is_empty());
|
||
assert!(g.piles[&PileType::Foundation(0)].claimed_suit().is_none());
|
||
|
||
// A different Ace can now claim slot 0.
|
||
let t0 = g.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||
t0.cards.clear();
|
||
t0.cards.push(Card { id: 2, suit: Suit::Spades, rank: Rank::Ace, face_up: true });
|
||
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
|
||
assert_eq!(g.piles[&PileType::Foundation(0)].claimed_suit(), Some(Suit::Spades));
|
||
}
|
||
|
||
/// Successive Aces from the waste pile distribute across slots 0..=3 in
|
||
/// order — the player picks the slot, but `move_cards` accepts any
|
||
/// empty-slot placement for an Ace.
|
||
#[test]
|
||
fn multiple_aces_distribute_across_slots() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
let aces = [
|
||
(Suit::Clubs, 10),
|
||
(Suit::Diamonds, 11),
|
||
(Suit::Hearts, 12),
|
||
(Suit::Spades, 13),
|
||
];
|
||
for (slot, (suit, id)) in aces.iter().enumerate() {
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
|
||
id: *id, suit: *suit, rank: Rank::Ace, face_up: true,
|
||
});
|
||
g.move_cards(PileType::Waste, PileType::Foundation(slot as u8), 1).unwrap();
|
||
}
|
||
for (slot, (suit, _)) in aces.iter().enumerate() {
|
||
assert_eq!(
|
||
g.piles[&PileType::Foundation(slot as u8)].claimed_suit(),
|
||
Some(*suit),
|
||
"slot {slot} should claim {suit:?}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Auto-complete prefers the foundation slot whose claimed suit matches
|
||
/// the candidate card's suit, even if an empty slot exists at a lower
|
||
/// index.
|
||
#[test]
|
||
fn next_auto_complete_move_picks_slot_with_matching_claim() {
|
||
let mut g = new_game();
|
||
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||
for i in 0..7 {
|
||
g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear();
|
||
}
|
||
// Slot 0 is empty; slot 1 already claims Hearts via Ace of Hearts.
|
||
g.piles.get_mut(&PileType::Foundation(1)).unwrap().cards.push(Card {
|
||
id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true,
|
||
});
|
||
// Tableau 0 holds the 2 of Hearts to play.
|
||
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards.push(Card {
|
||
id: 2, suit: Suit::Hearts, rank: Rank::Two, face_up: true,
|
||
});
|
||
g.is_auto_completable = true;
|
||
|
||
let mv = g.next_auto_complete_move().expect("auto-complete must find slot 1");
|
||
assert_eq!(mv.0, PileType::Tableau(0));
|
||
assert_eq!(
|
||
mv.1,
|
||
PileType::Foundation(1),
|
||
"must target the Hearts-claimed slot, not the empty slot 0",
|
||
);
|
||
}
|
||
}
|