5430d4c3ab
U (undo), N (new game), Z (zen), D/Space (draw) and G (forfeit) were not gated on an active drag, so pressing one mid-drag fired a StateChangedEvent whose card re-sync inserted a `CardAnim` on the dragged cards — fighting `follow_drag`'s per-frame Transform writes (jittering XY, animated Z) and leaving `DragState` origin indices stale against the mutated state. `handle_keyboard_core` and `handle_keyboard_forfeit` now reuse the mutual-exclusion guard `handle_selection_keys` already documents: skip while `DragState` is non-idle, unless it is the keyboard-lift sentinel (`KEYBOARD_DRAG_TOUCH_ID`), whose lift is already dropped cleanly by `clear_selection_on_state_change`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
940 lines
36 KiB
Rust
940 lines
36 KiB
Rust
use super::*;
|
|
use crate::layout::compute_layout;
|
|
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
|
|
|
fn clear_test_piles(game: &mut GameState) {
|
|
game.set_test_stock_cards(Vec::new());
|
|
game.set_test_waste_cards(Vec::new());
|
|
for foundation in [
|
|
Foundation::Foundation1,
|
|
Foundation::Foundation2,
|
|
Foundation::Foundation3,
|
|
Foundation::Foundation4,
|
|
] {
|
|
game.set_test_foundation_cards(foundation, Vec::new());
|
|
}
|
|
for tableau in [
|
|
Tableau::Tableau1,
|
|
Tableau::Tableau2,
|
|
Tableau::Tableau3,
|
|
Tableau::Tableau4,
|
|
Tableau::Tableau5,
|
|
Tableau::Tableau6,
|
|
Tableau::Tableau7,
|
|
] {
|
|
game.set_test_tableau_cards(tableau, Vec::new());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dragged_card_z_matches_resting_stack_step() {
|
|
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
|
let step = dragged_card_z(1) - dragged_card_z(0);
|
|
assert!(
|
|
step > 0.02,
|
|
"drag step must exceed Android overlay local_z, got {step}"
|
|
);
|
|
assert!(
|
|
step + 1e-4 >= STACK_FAN_FRAC,
|
|
"drag step must stay aligned with resting stack spacing, got {step}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn point_in_rect_inside_returns_true() {
|
|
let center = Vec2::new(10.0, 20.0);
|
|
let size = Vec2::new(40.0, 60.0);
|
|
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
|
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
|
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
|
}
|
|
|
|
#[test]
|
|
fn point_in_rect_on_edge_returns_true() {
|
|
let center = Vec2::ZERO;
|
|
let size = Vec2::new(10.0, 10.0);
|
|
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
|
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
|
}
|
|
|
|
#[test]
|
|
fn point_in_rect_outside_returns_false() {
|
|
let center = Vec2::ZERO;
|
|
let size = Vec2::new(10.0, 10.0);
|
|
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
|
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
|
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_picks_top_of_tableau() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
|
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
|
// Its position: base.y + fan * 6.
|
|
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
assert_eq!(result.1, 6);
|
|
assert_eq!(result.2.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
|
// Reproduces the reported "drags the wrong waste card" bug: with several
|
|
// cards in the waste, clicking the visible top must pick the actual top
|
|
// (last index), not the buffer card underneath it.
|
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
clear_test_piles(&mut game);
|
|
let waste = vec![
|
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
|
];
|
|
game.set_test_waste_cards(waste.clone());
|
|
|
|
let top_index = waste.len() - 1; // 2 = the visible top
|
|
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
|
assert_eq!(
|
|
result.2,
|
|
vec![waste[top_index].clone()],
|
|
"drags the top card only"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_picks_lone_waste_card() {
|
|
// "can't play the first card in the stock" — a waste of one card must
|
|
// still be draggable.
|
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
clear_test_piles(&mut game);
|
|
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
|
game.set_test_waste_cards(vec![card.clone()]);
|
|
|
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
|
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
|
assert_eq!(result.0, KlondikePile::Stock);
|
|
assert_eq!(result.1, 0);
|
|
assert_eq!(result.2, vec![card]);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
|
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
|
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
|
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
|
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
|
// fanned card's click target lands on the card beneath it — so dragging
|
|
// the visible top card plays the wrong one.
|
|
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
|
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
|
|
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
|
let cw = layout.card_size.x;
|
|
let base = layout.pile_positions[&KlondikePile::Stock];
|
|
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
layout.pile_positions.insert(
|
|
KlondikePile::Tableau(Tableau::Tableau2),
|
|
Vec2::new(t1.x + cw * 1.03, t1.y),
|
|
);
|
|
|
|
clear_test_piles(&mut game);
|
|
let waste = vec![
|
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
|
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
|
];
|
|
game.set_test_waste_cards(waste.clone());
|
|
|
|
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
|
let top_index = waste.len() - 1;
|
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
|
|
|
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
|
assert!(
|
|
(pos.x - expected).abs() < 1e-3,
|
|
"hit-test must use the shared waste fan step"
|
|
);
|
|
// The old fixed constant would have drifted from the renderer here.
|
|
let old = base.x + 2.0 * cw * 0.28;
|
|
assert!(
|
|
(pos.x - old).abs() > 1.0,
|
|
"shared step must differ from the old fixed step under tight spacing"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_skips_face_down_cards() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
|
|
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
|
// the bottom (index 6). Click at the topmost face-down card's
|
|
// position — its full body is partly visible above the fanned
|
|
// face-up card, but the iterator should skip face-down cards and
|
|
// the cursor sits above the face-up card's AABB, so the result
|
|
// is None.
|
|
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
|
// Regression test for the bug where input_plugin's hit-testing used
|
|
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
|
// at 0.12 — so for any column with face-down cards above the
|
|
// face-up bottom card, clicking the visible card face missed the
|
|
// hit-test box and only the bottom strip of the card responded.
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
|
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
|
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
|
assert_eq!(result.1, 6);
|
|
assert_eq!(result.2.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_returns_run_when_picking_mid_stack() {
|
|
// Manually construct a tableau with three face-up cards all stacked.
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
|
|
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
|
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
|
assert_eq!(start, 1);
|
|
assert_eq!(ids, vec![queen, jack]);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_skips_non_top_waste_card() {
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
|
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
// Both cards in waste sit at the same (x, y). Clicking should pick
|
|
// the visually top card (three_hearts), with count = 1.
|
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
|
assert_eq!(pile, KlondikePile::Stock);
|
|
assert_eq!(start, 1);
|
|
assert_eq!(ids, vec![three_hearts]);
|
|
}
|
|
|
|
#[test]
|
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
|
let mut game = game;
|
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
let target = find_drop_target(
|
|
pos,
|
|
&game,
|
|
&layout,
|
|
&KlondikePile::Tableau(Tableau::Tableau7),
|
|
);
|
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
|
}
|
|
|
|
#[test]
|
|
fn find_drop_target_returns_none_for_origin() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
|
let target = find_drop_target(
|
|
pos,
|
|
&game,
|
|
&layout,
|
|
&KlondikePile::Tableau(Tableau::Tableau4),
|
|
);
|
|
assert_eq!(target, None);
|
|
}
|
|
|
|
#[test]
|
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
// Tableau 6 has 7 cards.
|
|
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
|
// Expected: card_height + 6 fan steps.
|
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
|
assert!(
|
|
(size.y - expected).abs() < 1e-3,
|
|
"expected {expected}, got {}",
|
|
size.y
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
|
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
|
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
|
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
|
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
|
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
|
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
|
let cursor = Vec2::new(top_card_x, waste_base.y);
|
|
|
|
let result = find_draggable_at(cursor, &game, &layout);
|
|
assert!(
|
|
result.is_some(),
|
|
"top fanned waste card must be hittable at its visual X position"
|
|
);
|
|
let (pile, _start, ids) = result.unwrap();
|
|
assert_eq!(pile, KlondikePile::Stock);
|
|
assert_eq!(
|
|
ids,
|
|
vec![four_clubs],
|
|
"only the top card is draggable from waste"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
// Clear tableau 0 so it's an empty slot.
|
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
|
let result = find_draggable_at(pos, &game, &layout);
|
|
assert!(
|
|
result.is_none(),
|
|
"clicking an empty pile must not produce a draggable"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
|
for pile in [
|
|
KlondikePile::Stock,
|
|
KlondikePile::Foundation(Foundation::Foundation3),
|
|
] {
|
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
|
assert_eq!(size, layout.card_size);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #27 — best_destination pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn best_destination_returns_none_when_no_legal_move() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
|
|
// Clear everything except one card that has nowhere to go.
|
|
clear_test_piles(&mut game);
|
|
|
|
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
|
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
assert!(best_destination(&card, &game).is_none());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// best_tableau_destination_for_stack pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
|
|
clear_test_piles(&mut game);
|
|
|
|
// Only tableau 0 has anything; every other column is empty.
|
|
// A King is the only card that can go on an empty tableau column.
|
|
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
|
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
|
|
|
let result = best_tableau_destination_for_stack(
|
|
&king,
|
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
&game,
|
|
1,
|
|
);
|
|
// Result must be some other empty tableau column, never the source.
|
|
if let Some((dest, _)) = result {
|
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
|
|
clear_test_piles(&mut game);
|
|
|
|
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
|
// All other piles are empty — no legal tableau target.
|
|
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
|
|
|
let result = best_tableau_destination_for_stack(
|
|
&two_clubs,
|
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
|
&game,
|
|
1,
|
|
);
|
|
assert!(
|
|
result.is_none(),
|
|
"Two of Clubs has no legal tableau destination on empty piles"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// auto_move_for_run pure-function tests (issue #158)
|
|
// -----------------------------------------------------------------------
|
|
//
|
|
// These need real positions — `can_move_cards` validates against the live
|
|
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
|
|
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
|
|
// letting two moves build a face-up [Three, Two] run whose top card is
|
|
// foundation-eligible (the bait the pre-#158 code would take).
|
|
|
|
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
|
|
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
|
|
/// Returns the game with a 2-card face-up run on `run_on`.
|
|
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
|
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
|
let (ace, _) = game
|
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
|
.last()
|
|
.cloned()
|
|
.expect("seed deals a card on tableau 1");
|
|
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
|
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
|
.expect("ace moves to foundation");
|
|
game.move_cards(
|
|
KlondikePile::Tableau(two_from),
|
|
KlondikePile::Tableau(run_on),
|
|
1,
|
|
)
|
|
.expect("two stacks onto three");
|
|
game
|
|
}
|
|
|
|
#[test]
|
|
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
|
|
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
|
|
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
|
|
let cards = game.pile(run_pile);
|
|
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
|
let (clicked, _) = cards[cards.len() - 2].clone();
|
|
|
|
// The bait: the lone top card has a foundation move available.
|
|
assert!(
|
|
matches!(
|
|
best_destination(&top, &game),
|
|
Some(KlondikePile::Foundation(_))
|
|
),
|
|
"precondition: run top card must be foundation-eligible"
|
|
);
|
|
|
|
// Clicking the run base must move exactly the 2-card run to a tableau.
|
|
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
|
|
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
|
|
other => panic!("expected a whole-run tableau move, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
|
|
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
|
|
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
|
|
let cards = game.pile(run_pile);
|
|
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
|
let (clicked, _) = cards[cards.len() - 2].clone();
|
|
|
|
assert!(
|
|
matches!(
|
|
best_destination(&top, &game),
|
|
Some(KlondikePile::Foundation(_))
|
|
),
|
|
"precondition: run top card must be foundation-eligible"
|
|
);
|
|
|
|
// The 2-card run has no legal home — the top card's foundation move
|
|
// must NOT be taken as a substitute.
|
|
assert_eq!(
|
|
auto_move_for_run(&clicked, &run_pile, &game, 2),
|
|
None,
|
|
"an immovable clicked run must not fall back to a top-card move"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn auto_move_for_run_single_card_prefers_foundation() {
|
|
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
|
|
// is a lone face-up card that could go to the foundation OR onto the
|
|
// Three on tableau 5. Foundation must win.
|
|
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
|
|
let (ace, _) = game
|
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
|
.last()
|
|
.cloned()
|
|
.expect("seed 51 deals a card on tableau 1");
|
|
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
|
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
|
.expect("ace moves to foundation");
|
|
|
|
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
|
|
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
|
|
assert!(
|
|
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
|
|
"precondition: a tableau destination also exists"
|
|
);
|
|
|
|
assert!(
|
|
matches!(
|
|
auto_move_for_run(&two, &two_pile, &game, 1),
|
|
Some((KlondikePile::Foundation(_), 1))
|
|
),
|
|
"a lone top card still prefers the foundation"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Task #28 — find_hint pure-function tests
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn find_hint_finds_ace_to_foundation() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
|
|
// Place Ace of Clubs on top of tableau 0.
|
|
clear_test_piles(&mut game);
|
|
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
|
|
|
let hint = find_hint(&game);
|
|
assert!(hint.is_some(), "should find a hint");
|
|
let (from, to) = hint.unwrap();
|
|
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
|
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
|
/// the "is there actually a game?" gating lives in
|
|
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
|
/// "No game to forfeit" toast instead of failing silently.
|
|
#[test]
|
|
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
|
// Build the system param state by hand so we don't rely on a
|
|
// full Bevy app: the assertion is that the function returns
|
|
// early on the paused branch without calling write_message.
|
|
// This is verified by the plain `if paused { return; }` shape;
|
|
// the body is small enough to inspect by reading.
|
|
// (Higher-level integration coverage lives in the pause-plugin
|
|
// tests where `forfeit_app` simulates the full flow.)
|
|
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// all_hints / new-game window — pure-function tests added during refactor
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
|
/// are no other moves and the stock is non-empty.
|
|
#[test]
|
|
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
|
|
|
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
|
// move exists. Leave one card in the stock.
|
|
clear_test_piles(&mut game);
|
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
|
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
|
|
|
let hints = all_hints(&game);
|
|
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
|
let (from, to) = &hints[0];
|
|
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
|
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
|
}
|
|
|
|
// `all_hints` must be empty when both stock and waste are empty and no
|
|
// pile-to-pile move exists — the game is truly stuck.
|
|
// -----------------------------------------------------------------------
|
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
|
// `ShakeAnim` on the dragged cards. The audio cue
|
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
|
// to a smooth ease-out glide back to the origin pile.
|
|
//
|
|
// These tests build the component values exactly as `end_drag` and
|
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
|
// and mouse-button input, so we exercise the data path the same way the
|
|
// legacy `ShakeAnim` tests did.
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
|
fn build_drag_reject_animation(
|
|
drag_pos: Vec2,
|
|
drag_z: f32,
|
|
target_pos: Vec2,
|
|
stack_index: usize,
|
|
) -> CardAnimation {
|
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
|
}
|
|
|
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
|
/// rejection. With the shake → tween migration, the assertion changes
|
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
|
/// gets a CardAnimation" — same coverage, new component.
|
|
#[test]
|
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
|
use solitaire_core::Deck as D;
|
|
use solitaire_core::{Card, Rank, Suit};
|
|
// Simulate a stack drag of two cards.
|
|
let dragged_cards: Vec<Card> = vec![
|
|
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
|
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
|
];
|
|
|
|
let mut animated: Vec<Card> = Vec::new();
|
|
for card in &dragged_cards {
|
|
// In `end_drag` we iterate `drag.cards` and look up each card in
|
|
// `card_entities`. The cards we would insert a `CardAnimation` on
|
|
// must exactly match the dragged set.
|
|
animated.push(card.clone());
|
|
}
|
|
|
|
assert_eq!(
|
|
animated, dragged_cards,
|
|
"every card in drag.cards must receive a CardAnimation on rejection"
|
|
);
|
|
}
|
|
|
|
/// The `end` field of the inserted tween must equal the card's resting
|
|
/// slot in its origin pile — the position the card belongs at after a
|
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
|
/// and `sync_cards` would have to fight it back.
|
|
#[test]
|
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
|
|
|
assert!(
|
|
(anim.end - target_pos).length() < 1e-6,
|
|
"CardAnimation.end must match the origin slot's resting position. \
|
|
Expected {target_pos:?}, got {:?}",
|
|
anim.end
|
|
);
|
|
}
|
|
|
|
/// The `start` field of the inserted tween must equal the card's
|
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
|
/// released the card. This is what makes the glide feel like a
|
|
/// continuous return rather than a teleport-then-shake.
|
|
#[test]
|
|
fn rejected_drag_animation_starts_from_drag_position() {
|
|
let drag_pos = Vec2::new(640.0, 200.0);
|
|
let target_pos = Vec2::new(80.0, -120.0);
|
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
|
|
|
assert!(
|
|
(anim.start - drag_pos).length() < 1e-6,
|
|
"CardAnimation.start must match the drop-time transform position \
|
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
|
anim.start
|
|
);
|
|
// And the start must be visibly distinct from the origin slot — the
|
|
// whole point of the tween is that it visibly travels.
|
|
assert!(
|
|
(anim.start - anim.end).length() > 1.0,
|
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
|
anim.start,
|
|
anim.end
|
|
);
|
|
}
|
|
|
|
/// The tween duration is taken from the project-wide motion token so
|
|
/// designers can retune the feel from one place. Keeps the constant and
|
|
/// the call site honest.
|
|
#[test]
|
|
fn rejected_drag_animation_uses_correct_duration() {
|
|
let anim =
|
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
|
assert!(
|
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
|
anim.duration
|
|
);
|
|
}
|
|
|
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
|
/// cleanly into its rest position — overshoot on a rejection feels
|
|
/// jittery rather than forgiving.
|
|
#[test]
|
|
fn rejected_drag_animation_uses_responsive_curve() {
|
|
let anim =
|
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
|
assert_eq!(
|
|
anim.curve,
|
|
MotionCurve::Responsive,
|
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
|
so the card snaps back without bouncing past the slot"
|
|
);
|
|
}
|
|
|
|
/// The `start_z` of the tween must equal the card's drop-time z
|
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
|
/// travels home, then settles at the correct resting z.
|
|
#[test]
|
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
|
let stack_index = 2_usize;
|
|
let anim = build_drag_reject_animation(
|
|
Vec2::new(640.0, 200.0),
|
|
DRAG_Z,
|
|
Vec2::new(80.0, -120.0),
|
|
stack_index,
|
|
);
|
|
assert!(
|
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
|
);
|
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
|
assert!(
|
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
|
anim.end_z
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Hint system — async port (v0.18.0+)
|
|
//
|
|
// `handle_keyboard_hint` no longer runs the solver inline; it
|
|
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
|
// system in `pending_hint` turns into hint visuals one frame
|
|
// later. The behaviour contract this section pins is "pressing H
|
|
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
|
// covered end-to-end in `pending_hint::tests`.
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Pressing H on a non-paused, non-won game with a live
|
|
/// `GameStateResource` + `LayoutResource` must populate
|
|
/// `PendingHintTask`. The polling system, exercised in
|
|
/// `pending_hint::tests`, drives the result to a visual event.
|
|
#[test]
|
|
fn pressing_h_spawns_pending_hint_task() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins);
|
|
app.add_message::<InfoToastEvent>();
|
|
app.add_message::<HintVisualEvent>();
|
|
app.init_resource::<HintCycleIndex>();
|
|
app.init_resource::<HintSolverConfig>();
|
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.insert_resource(LayoutResource(compute_layout(
|
|
Vec2::new(1280.0, 800.0),
|
|
0.0,
|
|
0.0,
|
|
true,
|
|
)));
|
|
app.insert_resource(GameStateResource(GameState::new(
|
|
42,
|
|
DrawStockConfig::DrawOne,
|
|
)));
|
|
app.add_systems(Update, handle_keyboard_hint);
|
|
|
|
// Simulate the H key being pressed this frame.
|
|
{
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(KeyCode::KeyH);
|
|
input.clear();
|
|
input.press(KeyCode::KeyH);
|
|
}
|
|
app.update();
|
|
|
|
assert!(
|
|
app.world()
|
|
.resource::<crate::pending_hint::PendingHintTask>()
|
|
.is_pending(),
|
|
"pressing H must spawn an async hint task",
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Pointer-drag / keyboard mutual exclusion.
|
|
//
|
|
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
|
// transforms every frame would make the card re-sync insert a `CardAnim`
|
|
// on the same entities (two systems fighting over `Transform`) and leave
|
|
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
|
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
|
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
|
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
|
// drops that lift cleanly when the state moves.
|
|
// -----------------------------------------------------------------------
|
|
|
|
fn keyboard_core_app() -> App {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins);
|
|
app.add_message::<UndoRequestEvent>();
|
|
app.add_message::<NewGameRequestEvent>();
|
|
app.add_message::<InfoToastEvent>();
|
|
app.add_message::<DrawRequestEvent>();
|
|
app.add_message::<StartZenRequestEvent>();
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.init_resource::<DragState>();
|
|
app.add_systems(Update, handle_keyboard_core);
|
|
app
|
|
}
|
|
|
|
fn press_key(app: &mut App, key: KeyCode) {
|
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
|
input.release(key);
|
|
input.clear();
|
|
input.press(key);
|
|
}
|
|
|
|
fn message_count<M: Message>(app: &App) -> usize {
|
|
let messages = app.world().resource::<Messages<M>>();
|
|
let mut cursor = messages.get_cursor();
|
|
cursor.read(messages).count()
|
|
}
|
|
|
|
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
|
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
|
drag.committed = true;
|
|
drag.active_touch_id = active_touch_id;
|
|
}
|
|
|
|
#[test]
|
|
fn u_key_fires_undo_when_no_drag_active() {
|
|
let mut app = keyboard_core_app();
|
|
press_key(&mut app, KeyCode::KeyU);
|
|
app.update();
|
|
assert_eq!(
|
|
message_count::<UndoRequestEvent>(&app),
|
|
1,
|
|
"U with an idle DragState must fire UndoRequestEvent",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn u_key_ignored_during_committed_mouse_drag() {
|
|
let mut app = keyboard_core_app();
|
|
set_drag(&mut app, None); // mouse drag
|
|
press_key(&mut app, KeyCode::KeyU);
|
|
app.update();
|
|
assert_eq!(
|
|
message_count::<UndoRequestEvent>(&app),
|
|
0,
|
|
"U during a mouse drag must be swallowed",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn draw_key_ignored_during_touch_drag() {
|
|
let mut app = keyboard_core_app();
|
|
set_drag(&mut app, Some(7)); // real touch id
|
|
press_key(&mut app, KeyCode::KeyD);
|
|
app.update();
|
|
assert_eq!(
|
|
message_count::<DrawRequestEvent>(&app),
|
|
0,
|
|
"D during a touch drag must be swallowed",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn u_key_allowed_during_keyboard_lift() {
|
|
let mut app = keyboard_core_app();
|
|
set_drag(
|
|
&mut app,
|
|
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
|
);
|
|
press_key(&mut app, KeyCode::KeyU);
|
|
app.update();
|
|
assert_eq!(
|
|
message_count::<UndoRequestEvent>(&app),
|
|
1,
|
|
"the keyboard-lift sentinel must not block core shortcuts",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn g_key_forfeit_ignored_during_mouse_drag() {
|
|
let mut app = App::new();
|
|
app.add_plugins(MinimalPlugins);
|
|
app.add_message::<ForfeitRequestEvent>();
|
|
app.init_resource::<ButtonInput<KeyCode>>();
|
|
app.init_resource::<DragState>();
|
|
app.add_systems(Update, handle_keyboard_forfeit);
|
|
|
|
set_drag(&mut app, None);
|
|
press_key(&mut app, KeyCode::KeyG);
|
|
app.update();
|
|
assert_eq!(
|
|
message_count::<ForfeitRequestEvent>(&app),
|
|
0,
|
|
"G during a mouse drag must be swallowed",
|
|
);
|
|
}
|