Files
Ferrous-Solitaire/solitaire_engine/src/input_plugin/tests.rs
T
funman300 5430d4c3ab
Test / fmt (pull_request) Successful in 6s
Test / test (pull_request) Successful in 4m43s
fix(engine): swallow game-mutating hotkeys during pointer drags
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>
2026-07-14 11:35:47 -07:00

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",
);
}